propTypes、以 state 為中心去思考


Posted by saffran on 2021-02-25

useEffect

通常,想要執行什麼事情時,會寫在兩個地方:

  1. useEffect 裡面
  2. event handler 裡面

不會把要做的事情直接寫在 function component 裡面,原因為:
如果直接寫在 function component 裡面,那就是「每次 render 時都會執行」

但是這樣一邊 render 一邊執行要做的事情,會阻礙 render 的進行,效能會比較差

比較好的做法是:等 render 完成之後,再執行要做的事情

render 機制

如果要避免 re-render 的話,就會使用到 useCallback, useMemo, memo

安裝 ESLint extension (增進程式碼品質的第一種方式)


安裝完之後,要把 vs code 右下角的 ESLint 打勾來啟用它

利用 propTypes 驗證 props (增進程式碼品質的第二種方式)

官方文件 Typechecking With PropTypes

propTypes 是 React 提供的方法,用來檢查 prop 的型態是否正確
如果沒有按照 propTypes 的規則去使用這個 prop 的話,就會在 console 顯示 warning(但不會影響到程式碼的執行)

要使用 propTypes,就要先改一下 ESLint 的規則,步驟如下:
先在根目錄新增一個檔案,叫做 .eslintrc.json,在裡面要寫的內容如下

"extends": ["react-app"] 意思是:先延續 Create React App 的規則
"rules" 意思是:再新增我自己訂的規則,"warn" 就是 warning 的意思

.eslintrc.json:

{
  "extends": ["react-app"],
  "rules": {
    "react/prop-types": "warn"
  }
}

加上 "react/prop-types" 這個 rule 之後,就會規定我「每個 component 都一定要寫 prop types」,寫起來會有一點麻煩,但好處是可以幫我避免一些預期之外的錯誤

在 TodoItem.js 使用 propTypes

要使用 propTypes 之前,要先在最上方引入:
PropTypes 是 React 提供的,會幫助我定義一些已經有的類型

import PropTypes from "prop-types";

然後,就可以幫 TodoItem component 定義 propTypes 了(就寫在 TodoItem component 的後面):
後面加上 .isRequired 意思就是:一定要傳這個 prop,否則就會在 console 出現 warning
PropTypes.func 的 func 是 function 的簡寫

TodoItem.propTypes = {
  className: PropTypes.string,
  size: PropTypes.string.isRequired,
  todo: PropTypes.object,
  handleDeleteTodo: PropTypes.func,
  handleToggleIsFinished: PropTypes.func,
};

如果想要更詳細的寫出 todo 這個 object 是長什麼樣子,例如 todo 這個 object 裡面會用到 id, content, isFinished 這些 key,就可以用 PropTypes.shape() 來寫:
bool 是 boolean 的簡寫

TodoItem.propTypes = {
  className: PropTypes.string,
  size: PropTypes.string.isRequired,
  todo: PropTypes.shape({
    id: PropTypes.number,
    content: PropTypes.string,
    isFinished: PropTypes.bool,
  }),
  handleDeleteTodo: PropTypes.func,
  handleToggleIsFinished: PropTypes.func,
};

以 state 為中心去思考

todo list 範例連結

如果要把這個 todo list 的畫面變成 json 格式的資料存下來,可以這樣寫:

// 放在 state 裡面
{
  inputValue: 'abc',
  todos: [
    {
      content: 'work out',
      isChecked: true
    },
    {
      content: 'buy milk',
      isChecked: false
    }
  ],
  itemLeftCount: 1,
  filter: 'active'
}

// 沒有放在 state 裡面
todoId: 2

以資料為中心去思考,有兩個重點:

重點一:資料改變會需要改變畫面,這個資料就是 state

必備的資料(無法透過其他資料算出來的),才會是 state

資料改變但不影響畫面,它就會用 useRef 來存

當我想要存某個狀態,但這個狀態不影響畫面時,就可以用 useRef 來存

例如:todo list 裡面的 todoId
因為畫面上不需要顯示 id,所以 todoId 不會被放在 state 裡面。當 todoId 遞增時,會用 useRef 來存

const todoId = useRef(0);

要用 todoId 的值時,就要寫 todoId.current 才會是我要的值
會需要用 .current 是因為 JS 的 pass by reference -> 一定要是一個「物件」的屬性,才可以被記下來

重點二:state vs derived state

derive 是「從…中得到」的意思

derived state 就是:不需要直接放在 state 裡面,它是可以被計算出來的 -> 可以透過現有的 state 去組合/計算而成 -> 從現有的 state 衍伸出來的就叫做 derived state

如果是 derived state 就不要放在 state 裡面,否則會很難維護

以上面的 todo list 來說,itemLeftCount 就是 derived state
我根本不需要把 itemLeftCount 放在 state 裡面,我只要去 todos 裡面計算出有多少筆資料的 isChecked 是 false,就會是 itemLeftCount 的結果

// 放在 state 裡面
{
  inputValue: 'abc',
  todos: [
    {
      content: 'work out',
      isChecked: true
    },
    {
      content: 'buy milk',
      isChecked: false
    }
  ],
  filter: 'active'
}

// 沒有放在 state 裡面
todoId: 2

App.js:
filteredTodos 就是:選擇不同的 filter 之後要相對應 render 出來的 todos
filteredTodos 是 derived state,因為可以從 filtertodos 的狀態來推算出 filteredTodos

export default function App() {
  const [value, setValue] = useState("");

  const [todos, setTodos] = useState([
    { id: 1, content: "aaa", isChecked: false },
    { id: 2, content: "bbb", isChecked: true },
  ]);

  const [filter, setFilter] = useState("active");

  // 可以從 filter 和 todos 來推算出 filteredTodos,所以 filteredTodos 是 derived state
  const filteredTodos = todos.filter((todo) => {
    if (filter === "all") return true;
    if (filter === "active") return !todo.isChecked;
    if (filter === "completed") return todo.isChecked;
  });

  console.log("render~");

  return (
    <div>
      todo:{" "}
      <input
        value={value}
        onChange={(e) => {
          setValue(e.target.value);
        }}
      />
      {filteredTodos.map((todo) => (
        <div key={todo.id}>{todo.content}</div>
      ))}
    </div>
  );
}

在 input 欄位每輸入一個字,在 console 就會印出一次 "render~"

原因為:
在 input 欄位每輸入一個字,就會 setValue 一次,所以就會 re-render 一次 -> 重新執行一次 App()

但是,我在 input 欄位輸入字,跟 filteredTodos 沒有任何關係,所以 filteredTodos 其實不需要跟著重新計算一次

useMemo

在 derived state 外面包一個 useMemo

使用 useMemo 之後,這個 derived state 就不需要每次 re-render 都跟著重新計算一次,只有在「depedency array 裡面的 state 改變時」,才會重新計算一次

useMemo 裡面要傳入一個 function,然後要記得 return 東西

useMemo 錯誤使用 dependency array 的做法

useMemo 要傳入第二個參數(depedency array),如果第二個參數是傳一個「空陣列」的話,那就只有第一次 render 會計算 filteredTodos(印出 "calculate filteredTodos"),之後的 re-render 都不會再計算一次 filteredTodos

export default function App() {
  const [value, setValue] = useState("");

  const [todos, setTodos] = useState([
    { id: 1, content: "aaa", isChecked: false },
    { id: 2, content: "bbb", isChecked: true },
  ]);

  const [filter, setFilter] = useState("active");

  // 可以從 filter 和 todos 來推算出 filteredTodos,所以 filteredTodos 是 derived state
  const filteredTodos = useMemo(() => {
    console.log("calculate filteredTodos");
    return todos.filter((todo) => {
      if (filter === "all") return true;
      if (filter === "active") return !todo.isChecked;
      if (filter === "completed") return todo.isChecked;
    });
  }, []);

  console.log("render~");

  return (
    <div>
      todo:{" "}
      <input
        value={value}
        onChange={(e) => {
          setValue(e.target.value);
        }}
      />
      {filteredTodos.map((todo) => (
        <div key={todo.id}>{todo.content}</div>
      ))}
    </div>
  );
}

但是,把 useMemo 的 dependency array 傳一個空陣列,這樣其實是錯的
原因為:
用一個「清空」的按鈕來說明,就知道為什麼是錯的了
我在 input 欄位旁邊放上一個「清空」的按鈕,點擊就會把 todos 變為空陣列

export default function App() {
  const [value, setValue] = useState("");

  const [todos, setTodos] = useState([
    { id: 1, content: "aaa", isChecked: false },
    { id: 2, content: "bbb", isChecked: true },
  ]);

  const [filter, setFilter] = useState("active");

  // 可以從 filter 和 todos 來推算出 filteredTodos,所以 filteredTodos 是 derived state
  const filteredTodos = useMemo(() => {
    console.log("calculate filteredTodos");
    return todos.filter((todo) => {
      if (filter === "all") return true;
      if (filter === "active") return !todo.isChecked;
      if (filter === "completed") return todo.isChecked;
    });
  }, []);

  console.log("render~");

  return (
    <div>
      todo:{" "}
      <input
        value={value}
        onChange={(e) => {
          setValue(e.target.value);
        }}
      />
      <button
        onClick={() => {
          setTodos([]);
        }}
      >
        清空所有項目
      </button>
      {filteredTodos.map((todo) => (
        <div key={todo.id}>{todo.content}</div>
      ))}
    </div>
  );
}

但是,當我點擊「清空」按鈕後,會發現:頁面上的 todos 還在,並沒有被清空
其實,todos 陣列已經變成空陣列了(App() 有 re-render)

但是因為在 filteredTodosuseMemo 的 dependency array 是空陣列,任何的 state 改變都不會讓 filteredTodos 重新計算(依然還是舊的值),所以畫面並不會改變

但是,因為 filteredTodos 是透過 filtertodos 計算而成的,所以當 filtertodos 改變時,filteredTodos 應該要跟著改變才對

useMemo 正確使用 dependency array 的做法

所以,要在 useMemo 的 dependency array 裡面填入 filter, todos,意思就是:只有當 filtertodos 改變時,filteredTodos 才會重新計算一次

如果 filtertodos 都沒有改變,那 useMemo 會幫我記住「上一次計算的值」,所以不需要再計算一次

  // 可以從 filter 和 todos 來推算出 filteredTodos,所以 filteredTodos 是 derived state
  const filteredTodos = useMemo(() => {
    console.log("calculate filteredTodos");
    return todos.filter((todo) => {
      if (filter === "all") return true;
      if (filter === "active") return !todo.isChecked;
      if (filter === "completed") return todo.isChecked;
    });
  }, [filter, todos]);

useCallback 記住 function

useCallback 錯誤使用 dependency array 的做法

當我在 input 欄位輸入字時,會觸發 onChange 事件並執行 handleChange
handleChange 裡面會把 todos 陣列印出來

export default function App() {
  const [value, setValue] = useState("");

  const [todos, setTodos] = useState([
    { id: 1, content: "aaa", isChecked: false },
    { id: 2, content: "bbb", isChecked: true },
  ]);

  const [filter, setFilter] = useState("active");

  // 可以從 filter 和 todos 來推算出 filteredTodos,所以 filteredTodos 是 derived state
  const filteredTodos = useMemo(() => {
    console.log("calculate filteredTodos");
    return todos.filter((todo) => {
      if (filter === "all") return true;
      if (filter === "active") return !todo.isChecked;
      if (filter === "completed") return todo.isChecked;
    });
  }, [filter, todos]);

  const handleChange = useCallback((e) => {
    console.log(todos);
    setValue(e.target.value);
  }, []);

  console.log("render~");

  return (
    <div>
      todo: <input value={value} onChange={handleChange} />
      <button
        onClick={() => {
          setTodos([]);
        }}
      >
        清空所有項目
      </button>
      {filteredTodos.map((todo) => (
        <div key={todo.id}>{todo.content}</div>
      ))}
    </div>
  );
}

useCallback 的 dependency array 傳一個空陣列,這是錯誤的寫法
因為:
dependency array 傳一個空陣列代表「只有第一次 render 會產生新的 function,之後就會一直沿用這個 function」
useCallback 會幫我記住上一個 function,所以就算 todos 的 state 改變了,因為 handleChange 還是第一次 render 時的 function,所以在 function 裡面的 todos 就還是第一次 render 時的值,這樣就是錯的

useCallback 正確使用 dependency array 的做法

因為在 handleChange 裡面有用到 todos,所以要在 dependency array 裡面填入 todos,這樣每一次 re-render 之後才會重新宣告一個新的 handleChange function,才會拿到最新的 todos 的值

  const handleChange = useCallback(
    (e) => {
      console.log(todos);
      setValue(e.target.value);
    },
    [todos]
  );

總結

useMemo, useCallback, useEffect 的 dependency array 機制都是一樣的


#React







Related Posts

JavaScript 進階 05:this

JavaScript 進階 05:this

給自己的~froggy小筆記

給自己的~froggy小筆記

className 依動作變化

className 依動作變化


Comments